Skip to content
This repository has been archived by the owner on Jun 3, 2020. It is now read-only.

Add token refresh flow. #369

Closed
wants to merge 15 commits into from
Closed

Add token refresh flow. #369

wants to merge 15 commits into from

Conversation

simoneduca
Copy link
Contributor

Not working yet, just a proof of concept.
The first I was trying to get working was calling the refresh client method after 20 seconds the project loads the home page. But if you check the logs or the network call on preview we get nothing back.

@eatyourgreens
Copy link
Contributor

SW uses oauth.js, not auth.js, to get the access token, which is why you're getting nothing back from auth.checkBearerToken(). Auth is the module that PFE uses to log in and out, reset passwords and register new volunteers with Panoptes.

@simoneduca
Copy link
Contributor Author

I thought I could just import the Auth module, but obviously that doesn't work. @eatyourgreens Am I right in thinking that there are no methods in OAuth currently that check sessions/refresh it if needed that don't involve an iframe (which isn't used anymore, since your latest changes to the client)?

@eatyourgreens
Copy link
Contributor

Nothing in my changes touched the iframe code, so it's still in use for refreshing tokens, if you allow it in your browser settings.

One solution would be to mirror the call that the client makes before every request with the Auth module.
https://github.com/zooniverse/panoptes-javascript-client/blob/965580bba1ed57280482a11e3a3ff30b2c9cce2d/lib/api-client.js#L10-L13

apiClient.beforeEveryRequest = function() {
    return oauth.checkBearerToken();
  },

but you're right, the OAuth module doesn't have a checkBearerToken method.

@simoneduca
Copy link
Contributor Author

@eatyourgreens wouldn't that solution still rely on allowing iframes in the browser? Or do you mean it as "we need to write a method similar to checkBearerToke in OAuth, which doesn't rely on iframes"?

@eatyourgreens
Copy link
Contributor

@simoneduca zooniverse/panoptes-javascript-client#76 describes authorisation in more detail.

@simoneduca
Copy link
Contributor Author

Atm OAuth _refreshBearerToken relies on creating an iframe to check the token. I suppose I could just check sessionStorage, since that's available now. Would that make sense?

@simoneduca
Copy link
Contributor Author

Thanks, re-reading than now.

@eatyourgreens
Copy link
Contributor

oauth.listen(callback) might be the easiest way to be notified when oauth changes. Roger’s documentation for the API client has a description of the listen method, I think.

Just realised there’s a bug in SW’s use of storage too, in that the version number is part of the storage key so that volunteers couldn’t access their stored data if the version number ever changed. Also, the old data would never be deleted. This is a completely separate issue, however.

@simoneduca
Copy link
Contributor Author

simoneduca commented Feb 9, 2018

Cool, nice catch. How did you see that? Decoding the token?

@eatyourgreens
Copy link
Contributor

OAuth extends Model, which is an event emitter. There’s a little bit about it in the Readme for json-api-client too.

@eatyourgreens
Copy link
Contributor

The key bug? By looking at the names of the storage keys in the dev console.

@simoneduca
Copy link
Contributor Author

Yes. Which key? I can only see the keys access_token, expires_in and token-type.

@simoneduca
Copy link
Contributor Author

simoneduca commented Feb 9, 2018

Got it, just saw the issue.

@simoneduca
Copy link
Contributor Author

I was thinking of checking the token validity every time the user submits a classification. I think this would work fine for most projects, but I know some SW users can be on a single subject for more than two hours before they submit a classification. In that situation I think a warning to save their work and login before submitting should be enough.
Alternatively, listening on any changes in the token object could work, but I'm not sure where is the best place in the app to set app such listener.

@eatyourgreens
Copy link
Contributor

How about my suggestion earlier in this thread, to run the check before each request? #369 (comment)

@simoneduca
Copy link
Contributor Author

That's what I'd love to do and I intended my thought of checking the token before submitting classifications in that vein. But obviously I'm missing something. I don't know how to call it on every request. Do you mean to have a OAuth.checkBearerToken() for login, one for loading the app, for submitting the classification, etc.?

@eatyourgreens
Copy link
Contributor

Doesn’t the code in my comment work?

@simoneduca
Copy link
Contributor Author

Probably, but my question is about where to put it.

@eatyourgreens
Copy link
Contributor

SW is your app, so that’s up to you, but it should be added wherever the API client is first configured.

@simoneduca
Copy link
Contributor Author

simoneduca commented Feb 12, 2018

I've staged the latest changes https://preview.zooniverse.org/shakespearesworld/#!/ Angular is complaining about a circular dependency's been introduced. Looking at that.

@simoneduca
Copy link
Contributor Author

// @ngInject
function zooAPI(zooAPIConfig) {

function zooAPI($rootScope, zooAPIConfig) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocking comment, but what does zooAPIConfig do? I don't see it anywhere else in this function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Seems to be doing nothing.

@@ -4,10 +4,21 @@ require('./zooapi.module.js')
.factory('zooAPI', zooAPI);

var ApiClient = require('panoptes-client/lib/api-client');

var OAuth = require('panoptes-client/lib/oauth')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of interest, why are instances named with capital letters here, not lowercase? That naming convention usually indicates the name of a class, not an object.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure, when I was writing them I didn't know the convention and now I'm just sticking there for consistency, which is probably terrible 🤷‍♂️

console.log('Failed to refresh token: ', error);
alert('Your session has finished. Please save your work and login again.')
$rootScope.$broadcast('auth:loginChange');
OAuth.signOut();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this call OAuth.signIn(url) and sign them back in again with a new token?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should probably clarify here. They aren't signed out yet here, but their token is about to expire. Calling OAuth.signIn will redirect the browser to panoptes.zooniverse.org, then back to SW with a refreshed token for another 2 hours.

@@ -66,7 +66,7 @@ function buildScript(file) {
.pipe(gulpif(createSourcemap, sourcemaps.init()))
.pipe(gulpif(global.isProd, streamify(
uglify({
compress: { drop_console: true }
compress: { } // leave console.log in prod, for testing
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change should be reverted before this is merged to master.

Copy link
Contributor

@eatyourgreens eatyourgreens left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks okay to me, but I don't know Angular at all so did not understand the addition of $rootScope. Happy for this to be merged if it works, once the client has been updated. The uglify config should maybe be changed back before merging this to master.

@@ -93,12 +94,7 @@ function TranscribeController($stateParams, $modal, $scope, $window, Annotations
vm.subject = SubjectsFactory.current;
}

function subjectLoadError(result) {
if (result === 'outOfData') {
$scope.$broadcast('subject:outOfData');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you mean to delete this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did, because your pseudo-code didn't use it, so I thought deleting was implied in your comment. Was it not?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That code was an example of how to catch errors in a chain of promises, that's all.

What did this line do? It looks, to me, like it notifies when the workflow is out of subjects, or something like that. Won't removing it break SW wherever it listens for this event?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I've been looking into the same 10 lines for too long.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mind you, not adding it back in helps with stopping requests...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, you're adding it back in like this?

loadSubjects()
.catch(handleSubjectError)
.then(loadAggregations)
.catch(handleErrors)

in which case loadAggregations will always be run, even if loadSubjects throws an error, because you're catching errors mid-chain then recovering and continuing the chain. Read the section about Promise chains at MDN for more detail.

What you really want to be doing is interrupting the chain, then handling your errors at the end ie. use handleErrors to handle any errors that occurred while subjects were loading, or while aggregations were loading.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I was adding it back like this:

function loadSubject() {
        return SubjectsFactory.$getData($stateParams.subjectSet)
            .then(subjectLoaded, subjectLoadError)
            .then(loadAggregations)
            .catch(handleErrors);
    }

But I think it's equivalent.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's functionally the same as the pseudo-code I posted. A catch block is equivalent to passing a second argument to then. I think the MDN docs explain that too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I just remove subjectLoadError then (provided I find another decent place for the out-of-date message)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read that. That's why I said they are equivalent.

@eatyourgreens
Copy link
Contributor

With the latest changes, if I uncomment the alert(), I'm still seeing the alert twice if I open the transcription interface while logged in, but with an expired token. Is the request to load subjects still firing after the request to load subject sets fails? I don't know how to debug an Angular app, so I'm lost trying to see what's going on, short of watching for API requests in the network panel of the dev tools.

@simoneduca
Copy link
Contributor Author

simoneduca commented Mar 8, 2018

Is the request to load subjects still firing after the request to load subject sets fails?

It is and I don't know how to stop it. I don't think the problem is angular-specific.

@eatyourgreens
Copy link
Contributor

eatyourgreens commented Mar 8, 2018

That's what my pseudo-code example was supposed to be showing: that subjects and aggregations could be chained to the initial subject set loading, because it doesn't make sense to load subjects if subject sets aren't available. Failure anywhere would then abort the entire chain of requests.

Have you talked to @shaunanoordin about how he's handling expired sessions for ASM? zooniverse/anti-slavery-manuscripts#251 is pretty much ready to merge, and it's basically this PR but for ASM. Can that code be ported across to SW, or are the two apps too different for that?

@simoneduca
Copy link
Contributor Author

are the two apps too different for that?

I haven't talked to him but I had a several looks at the code and I think they are. For one thing, I can't see any other actions being chained after the promise is rejected.

@simoneduca
Copy link
Contributor Author

With the latest changes, I can still see the alert twice and two errors:
screen shot 2018-03-08 at 11 57 16

@simoneduca
Copy link
Contributor Author

OK, alert showing only once now. One error thrown caught at the end by the handler, as expected. The only thing left to fix, I think, is the transcribe page still showing. I need to cancel the route state transition to do that.

alert('Your session is expired. Press OK to save your work and start a new one.')
// Save any unsaved work and redirect to Panoptes for a new token.
// AnnotationsFactory.updateCache();
// OAuth.signIn($location.absUrl());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is saving your work and logging back in commented out for testing?

@eatyourgreens
Copy link
Contributor

Why do you need to cancel the transcribe page? I’m not following what the problem is there, if API errors are being handled correctly.

@eatyourgreens
Copy link
Contributor

Re. #369 (comment) your code doesn’t chain that function either, so you are essentially writing the same function that has already been written for ASM.

@simoneduca
Copy link
Contributor Author

Showing the transcribe page even in the background didn't seem very elegant. Now the error should be handled correctly. The alert shows once and on OK you're redirected, without the transcribe page being shown.

}
// We catch any request to access the transcribe route.
$transitions.onStart({ to: 'Transcribe'}, function(transition) {
token = $window.sessionStorage.getItem('panoptesClientOAuth_tokenDetails');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never, ever access the client's storage directly like this. You should always go through OAuth in order to validate the session.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added that like because token is still defined even when storage is cleared. Would var cachedToken = $window.sessionStorage.getItem('panoptesClientOAuth_tokenDetails'); be better?

Copy link
Contributor

@eatyourgreens eatyourgreens Mar 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely not. Don't load the client's token directly because a) you don't know what name the client code uses internally to store the token, and this may well change b) the client has a public method defined to get the token for you: checkBearerToken.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clearing storage logs your session out, so the client logs you back in if you still have a valid Panoptes session. It doesn't simulate your session having expired.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarify. What's the alternative then?

Copy link
Contributor

@eatyourgreens eatyourgreens Mar 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Set the token expires_in to something short, or change this line in oauth.js to run the check every 30s (for example).
var TOKEN_EXPIRATION_ALLOWANCE = 119.5 * 60 * 1000;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused. If you mean "hack the local copy of the client in node_modules", I'm already doing that; if you don't mean that, I don't know what you mean, sorry.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's exactly right. Hack the installed copy of oauth.js so that the client checks with Panoptes for a session, rather than using the stored token.

Don't forget to block third-party cookies too, since that's the bug you're addressing in this PR.


}
// We catch any request to access the transcribe route.
$transitions.onStart({ to: 'Transcribe'}, function(transition) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens here if my session expires while I'm navigating to a different page, such as the home page, or if I'm already transcribing when my session expires (which is the most likely scenario)? Correct me if I'm wrong, but this block looks like it only checks if I'm still logged in when I'm about to load the transcription page.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. Removing the Transcribe restriction will take care of navigating e.g. from /transcribe to home /. However, if my session expires while I'm transcribing and I click I'm done, it'll just look like I'm still logged in and new subject will be served, which isn't right. I'll have to catch the event too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You want to check for a valid token before every API request anyway, so just remove this check.

@simoneduca
Copy link
Contributor Author

I think this work as expected now. I went around in circles for a while trying to checking for ui-router transitions as well. But in the end, the bug was just how errors were handled in the promise chain. Thanks for the help, Jim.

@simoneduca
Copy link
Contributor Author

simoneduca commented Mar 8, 2018

The use case where session expires while users are transcribing and click I'm done seems still a bit odd. The classification options pop up
screen shot 2018-03-08 at 15 24 09
(Need to remove "View your work or", as it doesn't make sense since pdf functionality was removed.)
Then the session expired alert shows twice or thrice.

Note: reproduce this you'll have to change the staging appId to match the production one and use env=production in the url.

@eatyourgreens
Copy link
Contributor

Is that the classification save failing, but other API requests still being made anyway? Each request then firing off an alert box.

@eatyourgreens
Copy link
Contributor

ASM have launched their fix for classification save failures today, where they open a dialog box to tell you that there was a problem, save any unfinished work to local storage then refresh to log you back in.

@simoneduca
Copy link
Contributor Author

simoneduca commented Mar 14, 2018

@eatyourgreens would you mind having one more playing around with this please? I have looked at the AMS one more time and I'll chat with Shaun later, but I'd like to hear if you see any problems with how's the app behaving now. I could improve UI by using a modal rather than alert(), but apart from that I don't see what else I should change. Errors are handled correctly and login/redirects work with disabled 3rd party cookies. Cheers.

@eatyourgreens
Copy link
Contributor

@simoneduca did you manage to fix the bug you noted in this comment? #369 (comment)

@simoneduca
Copy link
Contributor Author

No that still happens, but I wouldn't call it a bug. Poor UX rather: the classifications-options modal shows up because clicking I'm done doesn't fire a request. That happens once an option is chosen and then the login redirect is triggered.

@eatyourgreens
Copy link
Contributor

Is it something that can be fixed? You said the alert was opening two or three times.

@eatyourgreens
Copy link
Contributor

Anyway, I can come back to this when I'm finished with the feedback PR, but that might not be this week.

@eatyourgreens
Copy link
Contributor

eatyourgreens commented Mar 15, 2018

If my token expires while I'm transcribing, the alert box pops up when I try to submit my classification but I'm not logged back in after pressing OK. I'm also seeing the alert box pop up more than once.

From the console errors, sign-in is trying to call this URL, which doesn't work. https://panoptes.zooniverse.org/users/sign_in/?now=1521113774236. I'm not sure why that's happening.

EDIT: nevermind. localhost isn't allowed to authenticate to Panoptes production, hence the error.

@simoneduca
Copy link
Contributor Author

simoneduca commented Mar 15, 2018

That's correct. I'm trying to solve this problem

If my token expires while I'm transcribing, the alert box pops up when I try to submit my classification but I'm not logged back in after pressing OK. I'm also seeing the alert box pop up more than once.

Roger pointed out a way of solving the multiple alerts issue is to wait until all promises are resolved on submitting a classification before doing anything else.

@simoneduca
Copy link
Contributor Author

@rogerhutchings using Promise.all(allPromises) doesn't seem to help. The app just gets stuck in a different kind a loop, as you noticed, where it just tries to log in and hence firing an alert.

function _submitToApi(newClassification) {

        var rejectedClassifications = [];

        localStorageService.set('classificationsToSubmit', localStorageService.get('classificationsToSubmit').concat([newClassification]));

        var classifications = localStorageService.get('classificationsToSubmit');

        var promises = classifications.map(function(classification) {
            var newResource = zooAPI.type('classifications')
                .create(classification);
            return newResource.save()
                .then(function (response) {
                    console.log('Classification saved', response.id);
                    return response;
                })
                .catch(function (error) {
                    console.log('Error submitting classification:', error);
                    rejectedClassifications.push(classification);
                    return error;
                });
        });
        return Promise.all(promises)
            .then(function(values) {
                console.log(values)
            });
    }

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants